/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.solr.search.facet;

import java.util.Arrays;
import org.apache.solr.JSONTestUtil;
import org.apache.solr.SolrTestCaseHS;
import org.apache.solr.common.params.SolrParams;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;

public class TestJsonRangeFacets extends SolrTestCaseHS {

  private static SolrInstances servers; // for distributed testing
  private static String cache;

  @SuppressWarnings("deprecation")
  @BeforeClass
  public static void beforeTests() throws Exception {
    systemSetPropertyEnableUrlAllowList(false);
    JSONTestUtil.failRepeatedKeys = true;

    // we need DVs on point fields to compute stats & facets
    if (Boolean.getBoolean(NUMERIC_POINTS_SYSPROP))
      System.setProperty(NUMERIC_DOCVALUES_SYSPROP, "true");

    initCore("solrconfig-tlog.xml", "schema_latest.xml");
    cache = Boolean.toString(random().nextBoolean());
  }

  /** Start all servers for cluster if they don't already exist */
  public static void initServers() throws Exception {
    if (servers == null) {
      servers = new SolrInstances(3, "solrconfig-tlog.xml", "schema_latest.xml");
    }
  }

  @SuppressWarnings("deprecation")
  @AfterClass
  public static void afterTests() throws Exception {
    systemClearPropertySolrEnableUrlAllowList();
    JSONTestUtil.failRepeatedKeys = false;
    if (servers != null) {
      servers.stop();
      servers = null;
    }
  }

  public void indexSimple(Client client) throws Exception {
    client.deleteByQuery("*:*");
    client.add(
        sdoc(
            "id",
            "1",
            "cat_s",
            "A",
            "where_s",
            "NY",
            "num_d",
            "4",
            "num_i",
            "2",
            "num_is",
            "4",
            "num_is",
            "2",
            "val_b",
            "true",
            "sparse_s",
            "one"),
        null);
    client.add(
        sdoc(
            "id", "2", "cat_s", "B", "where_s", "NJ", "num_d", "-9", "num_i", "-5", "num_is", "-9",
            "num_is", "-5", "val_b", "false"),
        null);
    client.add(sdoc("id", "3"), null);
    client.commit();
    client.add(
        sdoc(
            "id", "4", "cat_s", "A", "where_s", "NJ", "num_d", "2", "num_i", "3", "num_is", "2",
            "num_is", "3"),
        null);
    client.add(
        sdoc(
            "id",
            "5",
            "cat_s",
            "B",
            "where_s",
            "NJ",
            "num_d",
            "11",
            "num_i",
            "7",
            "num_is",
            "11",
            "num_is",
            "7",
            "sparse_s",
            "two"),
        null);
    client.commit();
    client.add(
        sdoc(
            "id", "6", "cat_s", "B", "where_s", "NY", "num_d", "-5", "num_i", "-5", "num_is", "-5"),
        null);
    client.commit();
  }

  public void testRangeOtherWhiteboxDistrib() throws Exception {
    initServers();
    Client client = servers.getClient(random().nextInt());
    client
        .queryDefaults()
        .set("shards", servers.getShards())
        .set("debugQuery", Boolean.toString(random().nextBoolean()));
  }

  public void testRangeOtherWhitebox() throws Exception {
    doRangeOtherWhitebox(Client.localClient());
  }

  /**
   * whitebox sanity checks that a shard request range facet that returns "between" or "after" will
   * cause the correct "actual_end" to be returned
   */
  private void doRangeOtherWhitebox(Client client) throws Exception {
    client.queryDefaults().set("cache", cache);
    indexSimple(client);

    // false is default, but randomly check explicit false as well
    final String nohardend = random().nextBoolean() ? "" : " hardend:false, ";

    { // first check some "phase #1" requests
      final SolrParams p =
          params(
              "q",
              "*:*",
              "rows",
              "0",
              "isShard",
              "true",
              "distrib",
              "false",
              "_facet_",
              "{}",
              "shards.purpose",
              "" + FacetModule.PURPOSE_GET_JSON_FACETS);
      final String basic_opts = "type:range, field:num_d, start:-5, end:10, gap:7, ";
      final String buckets =
          "buckets:[ {val:-5.0,count:1}, {val:2.0,count:2}, {val:9.0,count:1} ], ";

      client.testJQ(
          params(p, "json.facet", "{f:{ " + basic_opts + nohardend + " other:before}}"),
          "facets=={count:6, f:{"
              + buckets
              // before doesn't need actual_end
              + "   before:{count:1}"
              + "} }");
      client.testJQ(
          params(p, "json.facet", "{f:{" + basic_opts + nohardend + "other:after}}"),
          "facets=={count:6, f:{" + buckets + "   after:{count:0}, _actual_end:'16.0'" + "} }");
      client.testJQ(
          params(p, "json.facet", "{f:{ " + basic_opts + nohardend + "other:between}}"),
          "facets=={count:6, f:{" + buckets + "   between:{count:4}, _actual_end:'16.0'" + "} }");
      client.testJQ(
          params(p, "json.facet", "{f:{ " + basic_opts + nohardend + "other:all}}"),
          "facets=={count:6, f:{"
              + buckets
              + "   before:{count:1},"
              + "   after:{count:0},"
              + "   between:{count:4},"
              + "   _actual_end:'16.0'"
              + "} }");
      // with hardend:true, not only do the buckets change, but actual_end should not need to be
      // returned
      client.testJQ(
          params(p, "json.facet", "{f:{ " + basic_opts + " hardend:true, other:after}}"),
          "facets=={count:6, f:{"
              + "   buckets:[ {val:-5.0,count:1}, {val:2.0,count:2}, {val:9.0,count:0} ], "
              + "   after:{count:1}"
              + "} }");
    }

    { // now check some "phase #2" requests with refinement buckets already specified
      final String facet =
          "{ top:{ type:range, field:num_i, start:-5, end:5, gap:7,"
              + nohardend
              + "        other:all, facet:{ x:{ type:terms, field:cat_s, limit:1, refine:true } } } }";

      // the behavior should be the same, regardless of whether we pass actual_end to the shards
      // because in a "mixed mode" rolling update, the shards should be smart enough to re-compute
      // if the merging node is running an older version that doesn't send it
      for (String actual_end : Arrays.asList(", _actual_end:'9'", "")) {
        client.testJQ(
            params(
                "q",
                "*:*",
                "rows",
                "0",
                "isShard",
                "true",
                "distrib",
                "false",
                "shards.purpose",
                "" + FacetModule.PURPOSE_REFINE_JSON_FACETS,
                "json.facet",
                facet,
                "_facet_",
                "{ refine: { top: { between:{ x:{ _l:[B] } }" + actual_end + "} } }"),
            "facets=={top:{ buckets:[], between:{x:{buckets:[{val:B,count:3}] }} } }");
      }
    }
  }

  @Test
  public void testDateFacetsDistrib() throws Exception {
    initServers();
    Client client = servers.getClient(random().nextInt());
    client
        .queryDefaults()
        .set("shards", servers.getShards())
        .set("debugQuery", Boolean.toString(random().nextBoolean()));
    doDateFacets(client);
  }

  @Test
  public void testDateFacets() throws Exception {
    doDateFacets(Client.localClient());
  }

  private void doDateFacets(Client client) throws Exception {
    client.queryDefaults().set("cache", cache);
    client.deleteByQuery("*:*");
    boolean multiValue = random().nextBoolean();
    String dateField = multiValue ? "b_dts" : "b_dt";
    String dateRange = multiValue ? "b_drfs" : "b_drf";

    client.add(
        sdoc(
            "id",
            "1",
            "cat_s",
            "A",
            dateField,
            "2014-03-15T12:00:00Z",
            dateRange,
            "2014-03-15T12:00:00Z"),
        null);
    client.add(
        sdoc(
            "id",
            "2",
            "cat_s",
            "B",
            dateField,
            "2015-01-03T00:00:00Z",
            dateRange,
            "2015-01-03T00:00:00Z"),
        null);
    client.add(sdoc("id", "3"), null);
    client.commit();
    client.add(
        sdoc(
            "id",
            "4",
            "cat_s",
            "A",
            dateField,
            "2014-03-15T12:00:00Z",
            dateRange,
            "2014-03-15T12:00:00Z"),
        null);
    client.add(
        sdoc(
            "id",
            "5",
            "cat_s",
            "B",
            dateField,
            "2015-01-03T00:00:00Z",
            dateRange,
            "2015-01-03T00:00:00Z"),
        null);
    client.commit();
    client.add(
        sdoc(
            "id",
            "6",
            "cat_s",
            "B",
            dateField,
            "2014-03-15T12:00:00Z",
            dateRange,
            "2014-03-15T12:00:00Z"),
        null);
    client.commit();

    SolrParams p = params("q", "*:*", "rows", "0");
    for (String s : new String[] {dateField, dateRange}) {
      client.testJQ(
          params(
              p,
              "json.facet",
              "{date:{type : range, mincount:1, field :"
                  + s
                  + ",start:'2013-11-01T00:00:00Z',end:NOW,gap:'+90DAY'}}"),
          "facets=={count:6, date:{buckets:"
              + "[{val:\"2014-01-30T00:00:00Z\",count:3}, {val:\"2014-10-27T00:00:00Z\",count:2}]"
              + "}}");

      // with ranges
      client.testJQ(
          params(
              p,
              "json.facet",
              "{date:{type : range, mincount:1, field :"
                  + s
                  + ",ranges:[{from:'2013-11-01T00:00:00Z', to:'2014-04-30T00:00:00Z'},"
                  + "{from:'2015-01-01T00:00:00Z', to:'2020-01-30T00:00:00Z'}]}}"),
          "facets=={count:6, date:{buckets:"
              + "[{val:\"[2013-11-01T00:00:00Z,2014-04-30T00:00:00Z)\",count:3},"
              + " {val:\"[2015-01-01T00:00:00Z,2020-01-30T00:00:00Z)\",count:2}]"
              + "}}");
    }

    client.add(sdoc("id", "7", "cat_s", "B", dateRange, "[2010 TO 2014-05-21]"), null);
    client.commit();
    client.testJQ(
        params(
            p,
            "json.facet",
            "{date:{type : range, other:'before', field :"
                + dateRange
                + ",start:'2011-11-01T00:00:00Z',end:'2016-01-30T00:00:00Z',gap:'+1YEAR'}}"),
        "facets=={count:7, date:{buckets:["
            + "{val:\"2011-11-01T00:00:00Z\",count:1}, {val:\"2012-11-01T00:00:00Z\",count:1},"
            + "{val:\"2013-11-01T00:00:00Z\",count:4}, {val:\"2014-11-01T00:00:00Z\",count:2},"
            + "{val:\"2015-11-01T00:00:00Z\",count:0}"
            + "],before:{count:1}"
            + "}}");
  }

  @Test
  public void testRangeFacetWithRangesDistrib() throws Exception {
    initServers();
    Client client = servers.getClient(random().nextInt());
    client
        .queryDefaults()
        .set("shards", servers.getShards())
        .set("debugQuery", Boolean.toString(random().nextBoolean()));
    doRangeFacetWithRanges(client);
  }

  public void testRangeFacetWithRanges() throws Exception {
    Client client = Client.localClient();
    doRangeFacetWithRanges(client);
  }

  private void doRangeFacetWithRanges(Client client) throws Exception {
    client.queryDefaults().set("cache", cache);
    client.deleteByQuery("*:*");
    indexSimple(client);

    final SolrParams p = params("q", "*:*", "rows", "0");
    // with lower and upper include
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i, ranges:[{range:\"  [-5,7] \"}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[-5,7]\",count:5}]}}");

    // with lower include and upper exclude
    client.testJQ(
        params(p, "json.facet", "{price:{type : range,field : num_i,ranges:[{range:\"[-5,7)\"}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[-5,7)\",count:4}]}}");

    // with lower exclude and upper include
    client.testJQ(
        params(p, "json.facet", "{price:{type : range,field : num_i,ranges:[{range:\"(-5,7]\"}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(-5,7]\",count:3}]}}");

    // with lower and upper exclude
    client.testJQ(
        params(p, "json.facet", "{price:{type : range,field : num_i,ranges:[{range:\"(-5,7)\"}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(-5,7)\",count:2}]}}");

    // with other and include, they are not supported
    // but wouldn't throw any error as they are not consumed
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{range:\"(-5,7)\"}],include:\"lower\",other:[\"after\"]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(-5,7)\",count:2}]}}");

    // with mincount>0
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,mincount:3,"
                + "ranges:[{range:\"(-5,7)\"},{range:\"(-5,7]\"}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(-5,7]\",count:3}]}}");

    // with multiple ranges
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,"
                + "ranges:[{range:\"(-5,7)\"},{range:\"(-5,7]\"}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(-5,7)\",count:2},{val:\"(-5,7]\",count:3}]}}");

    // with * as one of the values
    client.testJQ(
        params(p, "json.facet", "{price:{type : range,field : num_i,ranges:[{range:\"(*,10]\"}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(*,10]\",count:5}]}}");
    client.testJQ(
        params(p, "json.facet", "{price:{type : range,field : num_i,ranges:[{range:\"[-5,*)\"}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[-5,*)\",count:5}]}}");
    client.testJQ(
        params(p, "json.facet", "{price:{type : range,field : num_i,ranges:[{range:\"[*,*]\"}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[*,*]\",count:5}]}}");
  }

  @Test
  public void testRangeFacetWithRangesInNewFormatDistrib() throws Exception {
    initServers();
    Client client = servers.getClient(random().nextInt());
    client
        .queryDefaults()
        .set("shards", servers.getShards())
        .set("debugQuery", Boolean.toString(random().nextBoolean()));
    doRangeFacetWithRangesInNewFormat(client);
  }

  @Test
  public void testRangeFacetWithRangesInNewFormat() throws Exception {
    Client client = Client.localClient();
    doRangeFacetWithRangesInNewFormat(client);
  }

  private void doRangeFacetWithRangesInNewFormat(Client client) throws Exception {
    client.queryDefaults().set("cache", cache);
    client.deleteByQuery("*:*");
    indexSimple(client);
    SolrParams p = params("q", "*:*", "rows", "0");

    // case without inclusive params
    client.testJQ(
        params(p, "json.facet", "{price:{type : range,field : num_i,ranges:[{from:-5, to:7}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[-5,7)\",count:4}]}}");

    // case without key param and to included
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{from:-5, to:7,inclusive_from:true ,inclusive_to:true}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[-5,7]\",count:5}]}}");

    // case with all params
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{from:-5, to:7,inclusive_from:true ,inclusive_to:true}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[-5,7]\",count:5}]}}");

    // from and to excluded
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{from:-5, to:7,inclusive_from:false ,inclusive_to:false}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(-5,7)\",count:2}]}}");

    // from excluded and to included
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{from:-5, to:7,inclusive_from:false ,inclusive_to:true}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(-5,7]\",count:3}]}}");

    // multiple ranges
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,include:[\"lower\"], outer:\"before\","
                + "ranges:[{from:-5, to:7,inclusive_from:false ,inclusive_to:true},{from:-5, to:7,inclusive_from:false ,inclusive_to:false}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(-5,7]\",count:3},{val:\"(-5,7)\",count:2}]}}");

    // with mincount>0
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,mincount:3"
                + "ranges:[{from:-5, to:7,inclusive_from:false ,inclusive_to:true},{from:-5, to:7,inclusive_from:false ,inclusive_to:false}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(-5,7]\",count:3}]}}");

    // mix of old and new formats
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,"
                + "ranges:[{from:-5, to:7,inclusive_from:false ,inclusive_to:true},{range:\"(-5,7)\"}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(-5,7]\",count:3},{val:\"(-5,7)\",count:2}]}}");

    // from==to
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{from:-5, to:-5,inclusive_from:false ,inclusive_to:true}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(-5,-5]\",count:0}]}}");
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{from:-5, to:-5,inclusive_from:false ,inclusive_to:false}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(-5,-5)\",count:0}]}}");
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{from:-5, to:-5,inclusive_from:true ,inclusive_to:false}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[-5,-5)\",count:0}]}}");
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{from:-5, to:-5,inclusive_from:true ,inclusive_to:true}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[-5,-5]\",count:2}]}}");

    // with * as one of the values
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{from:\"*\", to:10,inclusive_from:false ,inclusive_to:true}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"(*,10]\",count:5}]}}");
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{from:-5, to:\"*\",inclusive_from:true ,inclusive_to:false}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[-5,*)\",count:5}]}}");
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{from:-5,inclusive_from:true ,inclusive_to:false}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[-5,*)\",count:5}]}}");
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{from:\"*\", to:\"*\",inclusive_from:true ,inclusive_to:false}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[*,*)\",count:5}]}}");
    client.testJQ(
        params(
            p,
            "json.facet",
            "{price:{type : range,field : num_i,ranges:[{inclusive_from:true ,inclusive_to:false}]}}"),
        "facets=={count:6, price:{buckets:[{val:\"[*,*)\",count:5}]}}");
  }

  @Test
  public void testFacetValueTypes() throws Exception {
    doFacetValueTypeValidation(Client.localClient());
  }

  @Test
  public void testFacetValueTypeDistrib() throws Exception {
    initServers();
    Client client = servers.getClient(random().nextInt());
    client
        .queryDefaults()
        .set("shards", servers.getShards())
        .set("debugQuery", Boolean.toString(random().nextBoolean()));
    doFacetValueTypeValidation(client);
  }

  private void doFacetValueTypeValidation(Client client) throws Exception {
    indexSimple(client);

    // range faceting with start, end, and gap
    client.testXQ(
        params(
            "q",
            "*:*",
            "rows",
            "0",
            "json.facet",
            "{num:{type: range, field:num_i,start:0,gap:2,end:10,mincount:1,other:all}}"),
        "/response/lst[@name='facets']/long[@name='count'][.=6]", // count
        "/response/lst[@name='facets']/lst[@name='num']/arr[@name='buckets']/lst[1]/int[@name='val'][.=2]", // value
        "/response/lst[@name='facets']/lst[@name='num']/arr[@name='buckets']/lst[1]/long[@name='count'][.=2]", // count
        // number of entries
        "*[count(/response/lst[@name='facets']/lst[@name='num']/arr[@name='buckets']/lst)=2]",
        "/response/lst[@name='facets']/lst[@name='num']/lst[@name='before']/long[@name='count'][.=2]", // before
        "/response/lst[@name='facets']/lst[@name='num']/lst[@name='after']/long[@name='count'][.=0]", // after
        "/response/lst[@name='facets']/lst[@name='num']/lst[@name='between']/long[@name='count'][.=3]" // between
        );

    // range faceting with ranges specified
    client.testXQ(
        params(
            "q",
            "*:*",
            "rows",
            "0",
            "json.facet",
            "{num:{type: range, field:num_i,ranges:[{from:0, to:4},{from:-4,to:2}],mincount:1}}"),
        "/response/lst[@name='facets']/long[@name='count'][.=6]", // count
        "/response/lst[@name='facets']/lst[@name='num']/arr[@name='buckets']/lst[1]/str[@name='val'][.='[0,4)']", // value
        "/response/lst[@name='facets']/lst[@name='num']/arr[@name='buckets']/lst[1]/long[@name='count'][.=2]", // count
        // number of entries
        "*[count(/response/lst[@name='facets']/lst[@name='num']/arr[@name='buckets']/lst)=1]");
  }
}
